从真实事故出发:golang 内存问题排查指北 | 您所在的位置:网站首页 › x299e gaming内存问题 › 从真实事故出发:golang 内存问题排查指北 |
问题出现
出现报警!!!
在日常搬砖的某一天发现了某微服务 bytedance.xiaoming 服务有一些实例内存过高,达到 80%。而这个服务很久没有上线过新版本,所以可以排除新代码上线引入的问题。 发现问题后,首先进行了迁移实例,除一台实例留作问题排查外,其余实例进行了迁移,迁移过后新实例内存较低。但发现随着时间推移,迁移过的实例内存也有缓慢增高的现象,有内存泄漏的表现。 问题定位 推测一:怀疑是 goroutine 逃逸 排查过程通常内存泄露的主因就是 goroutine 过多,因此首先怀疑 goroutine 是否有问题,去看了 goroutine 发现很正常,总量较低且没有持续增长现象。(当时忘记截图了,后来补了一张图,但是 goroutine 数量一直是没有变化的) 排查结果没有 goroutine 逃逸问题。 推测二:怀疑代码出现了内存泄露 排查过程通过 pprof 进行实时内存采集,对比问题实例和正常实例的内存使用状况: 问题实例: 正常实例: 进一步看问题实例的 graph: 从中可以发现,metircs.flushClients()占用的内存是最多的,去定位源码: func (c *tagCache) Set(key []byte, tt *cachedTags) { if atomic.AddUint64(&c.setn, 1)&0x3fff == 0 { // every 0x3fff times call, we clear the map for memory leak issue // there is no reason to have so many tags // FIXME: sync.Map don't have Len method and `setn` may not equal to the len in concurrency env samples := make([]interface{}, 0, 3) c.m.Range(func(key interface{}, value interface{}) bool { c.m.Delete(key) if len(samples) 4.5),会默认采用更『激进』的策略使得内存重用更高效、延迟更低等诸多优化。带来的负面影响就是 RSS 并不会立刻下降,而是推迟到内存有一定压力时。我们的 go 版本是 1.15, 内核版本是 4.14,刚好中招! 排查结果go 编译器版本+系统内核版本命中了 go 的 runtime gc 策略,会使得在堆内存回收后,RSS 不下降。 问题解决 解决方法解决方法一共有两种: 一种是在环境变量里指定GODEBUG=madvdontneed=1这种方法可以强制 runtime 继续使用 MADV_DONTNEED.(参考:github.com/golang/go/i… )。但是启动了madvise dontneed 之后,会触发 TLB shootdown,以及更多的 page fault。延迟敏感的业务受到的影响可能会更大。因此这个环境变量需要谨慎使用! 升级 go 编译器版本到 1.16 以上看到 go 1.16 的更新说明。已经放弃了这个 GC 策略,改为了及时释放内存而不是等到内存有压力时的惰性释放。看来 go 官网也觉得及时释放内存的方式更加可取,在多数的情况下都是更为合适的。 附:解决 pprof 看 heap 使用的内存小于 RSS 很多的问题,可以通过手动调用 debug.FreeOSMemory 来解决,但是执行这个操作是有代价的。 同时 go1.13 版本中 FreeOSMemory 也不起作用了(github.com/golang/go/i… ),推荐谨慎使用。 实施结果我们选择了方案二。在升级 go1.16 之后,实例没有出现内存持续快速增长的现象。 再次用 pprof 去看实例情况,发现占用内存的函数也有变化。之前占用内存的 metrics.glob 已经降下去了。看来这个解决方法是有成效的。 遇到的其他坑在排查过程中发现的另一个可能引起内存泄露的问题(本服务未命中),在未开启 mesh 的情况下,kitc 的服务发现组件是有内存泄露的风险的。 从图中可以看到 cache.(*Asynccache).refresher 占用内存较多,且随着业务处理量的增多,其内存占用会一直不断的增长。 很自然的可以想到是在新建 kiteclient 的时候,可能有重复构建 client 的情况出现。于是进行了代码排查,并没有发现重复构建的情况。但是去看 kitc 的源码,可以发现,在服务发现时,kitc 里建立了一个缓存池 asyncache 来进行 instance 的存放。这个缓存池每 3 秒会刷新一次,刷新时调用 fetch,fetch 会进行服务发现。在服务发现时会根据实例的 host、port、tags(会根据环境 env 进行改变)不断地新建 instance,然后将 instance 存入缓存池 asyncache,这些 instance 没有进行清理也就没有进行内存的释放。所以这是造成内存泄露的原因。 解决方法该项目比较早,所以使用的框架比较陈旧,通过升级最新的框架可以解决此问题。 思考总结首先定义一下什么是内存泄露: 内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。 常见场景在 go 的场景中,常见的内存泄露问题有以下几种: 1. goroutine 导致内存泄露(1)goroutine 申请过多 问题概述: goroutine 申请过多,增长速度快于释放速度,就会导致 goroutine 越来越多。 场景举例: 一次请求就新建一个 client,业务请求量大时 client 建立过多,来不及释放。 (2)goroutine 阻塞 ① I/O 问题 问题概述: I/O 连接未设置超时时间,导致 goroutine 一直在等待。 场景举例: 在请求第三方网络连接接口时,因网络问题一直没有接到返回结果,如果没有设置超时时间,则代码会一直阻塞。 ② 互斥锁未释放 问题概述: goroutine 无法获取到锁资源,导致 goroutine 阻塞。 场景举例: 假设有一个共享变量,goroutineA 对共享变量加锁但未释放,导致其他 goroutineB、goroutineC、...、goroutineN 都无法获取到锁资源,导致其他 goroutine 发生阻塞。 ③ waitgroup 使用不当 问题概述: waitgroup 的 Add、Done 和 wait 数量不匹配,会导致 wait 一直在等待。 场景举例: WaitGroup 可以理解为一个 goroutine 管理者。他需要知道有多少个 goroutine 在给他干活,并且在干完的时候需要通知他干完了,否则他就会一直等,直到所有的小弟的活都干完为止。我们加上 WaitGroup 之后,程序会进行等待,直到它收到足够数量的 Done()信号为止。假设 waitgroup Add(2), Done(1),那么此时就剩余一个任务未完成,于是 waitgroup 会一直等待。详细介绍可以看 Goroutine 退出机制 中的 waitgroup 章节。 2. select 阻塞问题概述: 使用 select 但 case 未覆盖全面,导致没有 case 就绪,最终 goroutine 阻塞。 场景举例: 通常发生在 select 的 case 覆盖不全,同时又没有 default 的时候,会产生阻塞。示例代码如下: func main() { ch1 := make(chan int) ch2 := make(chan int) ch3 := make(chan int) go Getdata("https://www.baidu.com",ch1) go Getdata("https://www.baidu.com",ch2) go Getdata("https://www.baidu.com",ch3) select{ case v:= |
CopyRight 2018-2019 实验室设备网 版权所有 |